D:\a\cssh-rs\cssh-rs\xtask\src\readme.rs
Line | Count | Source |
1 | | //! README help-section verification and update logic. |
2 | | //! |
3 | | //! The README embeds the `--help` output between two HTML comment delimiters: |
4 | | //! |
5 | | //! ````text |
6 | | //! <!-- HELP_OUTPUT_START --> |
7 | | //! ```cmd |
8 | | //! cssh-rs.exe --help |
9 | | //! <help content> |
10 | | //! ``` |
11 | | //! <!-- HELP_OUTPUT_END --> |
12 | | //! ```` |
13 | | //! |
14 | | //! [`check_readme_help`] fails when the embedded text differs from the live |
15 | | //! output. [`update_readme_help`] rewrites the README when they differ and |
16 | | //! signals the change to the caller so a pre-commit hook can abort. |
17 | | |
18 | | use std::sync::LazyLock; |
19 | | |
20 | | use anyhow::{bail, Context, Result}; |
21 | | use cssh_rs_meta::PACKAGE_NAME; |
22 | | |
23 | | const START_MARKER: &str = "<!-- HELP_OUTPUT_START -->"; |
24 | | const END_MARKER: &str = "<!-- HELP_OUTPUT_END -->"; |
25 | | static PREAMBLE: LazyLock<String> = |
26 | 1 | LazyLock::new(|| format!("\r\n```cmd\r\n{PACKAGE_NAME}.exe --help\r\n")); |
27 | | const POSTAMBLE: &str = "\r\n```\r\n"; |
28 | | |
29 | | /// All side-effecting operations required by this module. |
30 | | /// |
31 | | /// Implement with mocks in tests to achieve zero filesystem and process |
32 | | /// side-effects. |
33 | | pub trait ReadmeSystem { |
34 | | /// Run `cargo run --package cssh-rs -- --help` and return the captured output. |
35 | | /// |
36 | | /// # Errors |
37 | | /// |
38 | | /// Returns an error if the process cannot be started or exits non-zero. |
39 | | fn get_help_output(&self) -> Result<String>; |
40 | | |
41 | | /// Read the full contents of `README.md`. |
42 | | /// |
43 | | /// # Errors |
44 | | /// |
45 | | /// Returns an error if the file cannot be read. |
46 | | fn read_readme(&self) -> Result<String>; |
47 | | |
48 | | /// Write `content` to `README.md`. |
49 | | /// |
50 | | /// # Errors |
51 | | /// |
52 | | /// Returns an error if the write fails. |
53 | | fn write_readme(&self, content: &str) -> Result<()>; |
54 | | } |
55 | | |
56 | | /// Production implementation of [`ReadmeSystem`]. |
57 | | pub struct RealSystem; |
58 | | |
59 | | #[cfg_attr(coverage_nightly, coverage(off))] |
60 | | impl ReadmeSystem for RealSystem { |
61 | | fn get_help_output(&self) -> Result<String> { |
62 | | let output = std::process::Command::new("cargo") |
63 | | .args(["run", "--package", PACKAGE_NAME, "--", "--help"]) |
64 | | .output() |
65 | | .with_context(|| { |
66 | | format!("failed to run `cargo run --package {PACKAGE_NAME} -- --help`") |
67 | | })?; |
68 | | let raw = String::from_utf8_lossy(&output.stdout).into_owned(); |
69 | | Ok(raw) |
70 | | } |
71 | | |
72 | | fn read_readme(&self) -> Result<String> { |
73 | | std::fs::read_to_string("README.md").context("failed to read README.md") |
74 | | } |
75 | | |
76 | | fn write_readme(&self, content: &str) -> Result<()> { |
77 | | std::fs::write("README.md", content).context("failed to write README.md") |
78 | | } |
79 | | } |
80 | | |
81 | | /// Normalize raw `--help` output for comparison with the README section. |
82 | | /// |
83 | | /// Replaces lines that contain only whitespace with empty lines, normalizes |
84 | | /// all line endings to `\r\n`, and trims leading and trailing whitespace. |
85 | | /// |
86 | | /// # Arguments |
87 | | /// |
88 | | /// * `raw` - Raw output from `--help`, possibly with mixed line endings. |
89 | | /// |
90 | | /// # Returns |
91 | | /// |
92 | | /// Normalized string ready for comparison with the README section. |
93 | | /// |
94 | 6 | pub fn normalize_help_output(raw: &str) -> String { |
95 | 6 | let normalized: Vec<&str> = raw |
96 | 6 | .lines() |
97 | 9 | .map6 (|line| if line.trim().is_empty() { ""1 } else { line8 }) |
98 | 6 | .collect(); |
99 | 6 | let joined = normalized.join("\r\n"); |
100 | 6 | joined.trim().to_owned() |
101 | 6 | } |
102 | | |
103 | | /// Extract the help text embedded in the README between the delimiters. |
104 | | /// |
105 | | /// # Arguments |
106 | | /// |
107 | | /// * `readme` - Full README contents. |
108 | | /// |
109 | | /// # Returns |
110 | | /// |
111 | | /// The help content string (trimmed), or an error if either delimiter is missing. |
112 | | /// |
113 | | /// # Errors |
114 | | /// |
115 | | /// Returns an error if `<!-- HELP_OUTPUT_START -->` or `<!-- HELP_OUTPUT_END -->` |
116 | | /// is absent, or if the expected preamble/postamble structure is not found. |
117 | 7 | pub fn extract_readme_help_section(readme: &str) -> Result<&str> { |
118 | 7 | let start_marker_pos6 = readme |
119 | 7 | .find(START_MARKER) |
120 | 7 | .context("could not find <!-- HELP_OUTPUT_START --> in README.md")?1 ; |
121 | 6 | let end_marker_pos5 = readme |
122 | 6 | .find(END_MARKER) |
123 | 6 | .context("could not find <!-- HELP_OUTPUT_END --> in README.md")?1 ; |
124 | | |
125 | 5 | let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len(); |
126 | 5 | let content_end = end_marker_pos - POSTAMBLE.len(); |
127 | | |
128 | 5 | if content_start > content_end { |
129 | 0 | bail!("README help section delimiters are malformed or out of order"); |
130 | 5 | } |
131 | | |
132 | 5 | Ok(readme[content_start..content_end].trim()) |
133 | 7 | } |
134 | | |
135 | | /// Rebuild the README with the help section replaced by `new_help`. |
136 | | /// |
137 | | /// All content outside the delimiters and the fixed preamble/postamble is |
138 | | /// preserved byte-for-byte. |
139 | | /// |
140 | | /// # Arguments |
141 | | /// |
142 | | /// * `readme` - Full README contents. |
143 | | /// * `new_help` - Normalized help text to embed. |
144 | | /// |
145 | | /// # Returns |
146 | | /// |
147 | | /// New full README string. |
148 | | /// |
149 | | /// # Errors |
150 | | /// |
151 | | /// Returns an error if the delimiters are not found. |
152 | 2 | pub fn replace_readme_help_section(readme: &str, new_help: &str) -> Result<String> { |
153 | 2 | let start_marker_pos = readme |
154 | 2 | .find(START_MARKER) |
155 | 2 | .context("could not find <!-- HELP_OUTPUT_START --> in README.md")?0 ; |
156 | 2 | let end_marker_pos = readme |
157 | 2 | .find(END_MARKER) |
158 | 2 | .context("could not find <!-- HELP_OUTPUT_END --> in README.md")?0 ; |
159 | | |
160 | 2 | let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len(); |
161 | 2 | let content_end = end_marker_pos - POSTAMBLE.len(); |
162 | | |
163 | 2 | let before = &readme[..content_start]; |
164 | 2 | let after = &readme[content_end..]; |
165 | | |
166 | 2 | Ok(format!("{before}{new_help}{after}")) |
167 | 2 | } |
168 | | |
169 | | /// Compare the live `--help` output against the README's embedded help section. |
170 | | /// |
171 | | /// Prints a colored diff to stdout when they differ. |
172 | | /// |
173 | | /// # Arguments |
174 | | /// |
175 | | /// * `system` - Injected I/O provider. |
176 | | /// |
177 | | /// # Returns |
178 | | /// |
179 | | /// `Ok(())` if they match; an error describing the mismatch otherwise. |
180 | | /// |
181 | | /// # Errors |
182 | | /// |
183 | | /// Returns an error when the sections differ or when any I/O operation fails. |
184 | 2 | pub fn check_readme_help<S: ReadmeSystem>(system: &S) -> Result<()> { |
185 | 2 | let raw_help = system.get_help_output()?0 ; |
186 | 2 | let actual_help = normalize_help_output(&raw_help); |
187 | | |
188 | 2 | let readme = system.read_readme()?0 ; |
189 | 2 | let readme_help = extract_readme_help_section(&readme)?0 ; |
190 | | |
191 | 2 | if actual_help == readme_help { |
192 | 1 | log::info!("README.md help output is up to date."); |
193 | 1 | return Ok(()); |
194 | 1 | } |
195 | | |
196 | 1 | log::error!( |
197 | | "README.md help output is outdated!\n\ |
198 | | \n\ |
199 | | Differences found:\n\ |
200 | | ==================\n\ |
201 | | README has:\n\ |
202 | | {readme_help}\n\ |
203 | | \n\ |
204 | | Current --help output:\n\ |
205 | | {actual_help}\n\ |
206 | | \n\ |
207 | | ==> Run `cargo xtask update-readme-help` to fix this." |
208 | | ); |
209 | | |
210 | 1 | bail!("README.md help output is outdated") |
211 | 2 | } |
212 | | |
213 | | /// Ensure the README's embedded help section matches the live `--help` output, |
214 | | /// writing an updated README when they differ. |
215 | | /// |
216 | | /// # Arguments |
217 | | /// |
218 | | /// * `system` - Injected I/O provider. |
219 | | /// |
220 | | /// # Returns |
221 | | /// |
222 | | /// `Ok(true)` when the README was modified (the caller should exit with code 1 |
223 | | /// to abort a pre-commit hook); `Ok(false)` when already up to date. |
224 | | /// |
225 | | /// # Errors |
226 | | /// |
227 | | /// Returns an error when any I/O operation fails. |
228 | 2 | pub fn update_readme_help<S: ReadmeSystem>(system: &S) -> Result<bool> { |
229 | 2 | let raw_help = system.get_help_output()?0 ; |
230 | 2 | let actual_help = normalize_help_output(&raw_help); |
231 | | |
232 | 2 | let readme = system.read_readme()?0 ; |
233 | 2 | let readme_help = extract_readme_help_section(&readme)?0 ; |
234 | | |
235 | 2 | if actual_help == readme_help { |
236 | 1 | log::info!("README.md help section is up to date, nothing to be done."); |
237 | 1 | return Ok(false); |
238 | 1 | } |
239 | | |
240 | 1 | log::warn!("README.md help section is outdated - fixing it."); |
241 | 1 | let new_readme = replace_readme_help_section(&readme, &actual_help)?0 ; |
242 | 1 | system.write_readme(&new_readme)?0 ; |
243 | 1 | log::info!("README.md help section has been updated with current --help output."); |
244 | | |
245 | 1 | Ok(true) |
246 | 2 | } |
247 | | |
248 | | #[cfg(test)] |
249 | | #[path = "tests/test_readme.rs"] |
250 | | mod tests; |